10장 오래 걸리는 일 다루기

원서: 『API Design Patterns』 (JJ Geewax, Manning) 10장 기반 입문판

이 장에서 딱 5가지만: 1. 오래 걸리는 일은 "기다리지 말고 번호표를 주자" — 그 번호표가 LRO다 2. LRO 상태는 "끝났어?"라고 물어보거나(폴링), "다 되면 알려줘"라고 맡긴다(대기) 3. 실패도 결과의 한 종류다 — 에러는 운송장 안에 담겨 돌아온다 4. 진행 상황은 퍼센트 말고 "150개 중 23개 처리" 같은 구체적 숫자로 보여준다 5. 번호표(작업 기록)는 영원히 두지 않고 30일쯤 뒤에 버린다

개념 1개 = 섹션 1개로 갑니다. 천천히 따라오세요.


학습 목표

이 장을 끝내면 다음을 할 수 있습니다.

  • 동기 방식과 비동기 방식의 차이를 설명한다.
  • LRO(장기 실행 작업)가 무엇이고 왜 필요한지 설명한다.
  • 폴링과 대기, 두 가지 결과 확인 방법을 구분한다.
  • LRO의 에러가 일반 HTTP 에러와 어떻게 다른지 설명한다.
  • 진행 상황을 보여줄 때 퍼센트가 왜 부족한지 설명한다.
  • 작업을 취소·일시정지·재개·탐색하는 방법을 설명한다.
  • LRO를 언제까지 보관해야 하는지 설명한다.

10.1 오래 걸리는 일은 왜 골치 아플까

이런 적 있죠?

버튼을 눌렀는데 화면이 한참 멈춰 있던 적 있죠? "이거 고장 났나?" 싶어서 새로고침을 눌러버린 적도요.

코드에서도 똑같은 일이 일어납니다.

function main() {
    let name = generateChatRoomName();
    // 아래 한 줄이 30초 걸리면?
    let chatRoom = client.CreateChatRoom({ name: name });
    // 사용자: "버그인가?" → 프로세스 강제 종료!
    console.log(chatRoom.id);
}

이 호출 한 줄이 오래 걸리는 동안 할 수 있는 게 아무것도 없습니다. 얼마나 걸릴지도 모르고, 중간에 어디까지 됐는지도 모르고, 그만두라고 말할 방법도 없죠.

그게 바로 '오래 걸리는 작업' 문제다

오래 걸리는 일은 생각보다 흔합니다.

  • 다른 서비스에 접속해서 데이터를 받아오는 일
  • 기가바이트·테라바이트짜리 파일을 처리하는 일
  • 무거운 분석이나 변환 작업

이런 일을 그냥 "기다려" 식으로 처리하면 위에서 본 문제가 다 터집니다.

음식점으로 보면

비유 API/코드 위험·주의
패스트푸드점: "햄버거요" → 30초 뒤 바로 받음 동기 방식 — 요청하면 결과가 바로 옴 오래 걸리는 일에 쓰면 손님(사용자)이 계속 멈춰서 기다려야 함
고급 레스토랑: "스테이크요" → "42번입니다" → 나중에 호출 비동기 방식 — 번호표 먼저 주고 나중에 결과 번호표 관리가 필요함(어디까지 됐나, 취소 가능한가)

여기서 두 단어를 정리하고 갑니다.

동기적(Synchronous): 요청을 보내면 결과가 올 때까지 기다리는 방식. 전화 통화처럼 바로 대답을 받습니다.

비동기적(Asynchronous): 요청을 보내 두고 나중에 결과를 받는 방식. 택배를 주문하고 도착을 기다리는 것과 같습니다.

잘못된 예 vs 올바른 예

잘못된 예 — 오래 걸리는 일을 동기로
POST /chatRooms  →  (30초 멈춤)  →  ChatRoom 받음
사용자는 30초간 아무것도 못 함

올바른 예 — 비동기로 번호표 먼저
POST /chatRooms  →  즉시 "번호표(Operation)" 받음
사용자는 다른 일 하다가 나중에 결과 확인

10.2 LRO — '나중에 줄게' 번호표

이런 적 있죠?

택배를 시키면 '운송장 번호'를 받죠. 물건은 아직 안 왔지만, 그 번호로 "지금 어디쯤이야?"를 물어볼 수 있습니다.

오래 걸리는 API에도 이런 번호표가 필요합니다.

그게 바로 LRO다

오래 걸리는 작업을 추적하라고 서버가 주는 번호표, 그게 LRO입니다.

LRO (Long-Running Operation, 장기 실행 작업): 오래 걸리는 작업을 추적하기 위해 서버가 만들어 주는 기록. 택배 운송장 번호와 같은 역할입니다.

이 번호표는 또 다른 이름이 있습니다. 바로 API 프라미스입니다.

프라미스(Promise): "지금은 결과가 없지만 나중에 줄게"라고 약속하는 객체. 식당 진동벨처럼 "준비되면 알려줄게"라는 약속표입니다. (Python에서는 '퓨처(Future)'라고도 부릅니다.)

프로그래밍 프라미스와 비교

프로그래밍을 해봤다면 Promise를 들어봤을 겁니다. LRO는 그 Promise를 'API 버전'으로 만든 것입니다. 비슷하지만 딱 3가지가 다릅니다.

비유 프로그래밍 Promise API의 LRO
메모지 vs 등기우편 내 컴퓨터 메모리 안에만 있음 서버에 저장됨(고유 ID 있음)
컴퓨터 끄면 사라지는 메모 vs 안 사라지는 기록 프로세스 끄면 사라짐 프로세스 꺼도 남아 있음
결과만 적힌 쪽지 vs 진행상황까지 적힌 운송장 결과만 돌려줌 결과 + 진행 정보(메타데이터)까지

이 3가지 차이를 표로 정리하면:

비유 API/코드 위험·주의
운송장에 '받는 물건 종류'가 적힘 LRO는 Operation<결과타입, 메타데이터타입>을 돌려줌 그냥 ChatRoom이 아니라 Operation으로 감싸서 옴
운송장은 택배사 시스템에 보관됨 LRO는 서버에 영속 저장되고 고유 ID가 있음 내 프로그램이 죽어도 작업 기록은 서버에 남음
운송장에 '현재 위치'가 찍힘 LRO는 진행률·시작시각 같은 메타데이터를 추적 결과 외에 "지금 어디까지 됐나"도 볼 수 있음

코드로 살짝만

프로그래밍 Promise는 보통 이렇게 씁니다. (참고용이니 가볍게 보세요.)

// 방법 1: await로 결과가 나올 때까지 기다림
let factors = await factor(...);

// 방법 2: then/catch 콜백으로 처리
factor(...).then((r) => { /* 성공 */ })
           .catch((e) => { /* 실패 */ });

LRO도 "결과를 나중에 받는다"는 점은 똑같습니다. 다만 결과를 받는 방법(폴링·대기)이 따로 있는데, 곧 볼 겁니다.

전체 흐름 한 장으로

말로 풀면 이렇습니다.

1. 사용자: "백업해줘"  → 서버
2. 서버: 운송장(Operation) 만들어서 바로 돌려줌  → 사용자
3. 서버: 뒤에서 진짜 백업 시작 (백엔드)
4. 사용자: "다 됐어?" 물어봄 (폴링)  → 서버: "아직(done=false)"
5. ... 반복 ...
6. 백업 끝남 → 서버 기록 갱신 (done=true, 결과 채움)
7. 사용자: "다 됐어?"  → 서버: "응(done=true), 결과 여기"

핵심: 결과를 바로 안 주고, 운송장부터 준다. 결과는 나중에 운송장으로 조회합니다.


10.3 Operation — 번호표의 생김새

이런 적 있죠?

운송장을 보면 칸이 정해져 있죠. 운송장번호, 배송완료 여부, 현재 위치, 받은 물건. LRO의 번호표도 똑같이 칸이 정해져 있습니다. 그 번호표를 Operation이라고 부릅니다.

Operation의 4칸

Operation: LRO를 표현하는 데이터. 작업의 상태·결과·진행정보를 담는 그릇입니다. (운송장 한 장이라고 보면 됩니다.)

interface Operation<ResultT, MetadataT> {
    id: string;                          // 운송장번호
    done: boolean;                       // 배송완료?
    result?: ResultT | OperationError;   // 받은 물건 또는 에러
    metadata?: MetadataT;                // 현재 위치 같은 진행정보
}
비유 (운송장) API/코드 위험·주의
운송장번호 id — 이 작업의 고유 식별자 이 id로만 작업을 조회·취소할 수 있음
배송완료 도장 done — true면 끝, false면 진행 중 done이 false인데 result를 읽으면 안 됨(아직 없음)
받은 물건 result — 성공 결과 또는 에러 성공이면 결과, 실패면 에러가 같은 칸에 들어옴
현재 위치 metadata — 진행 정보 작업마다 담는 내용이 다름(자유롭게 설계)

여기서 <ResultT, MetadataT>라는 게 보일 텐데, 겁먹지 마세요.

제네릭 매개변수화: 타입을 빈칸으로 두고 나중에 채우는 방식. 운송장 양식은 똑같고, '받는 물건 종류' 칸만 택배마다 다르게 채우는 것과 같습니다. Operation<ChatRoom, ...>이면 결과가 ChatRoom이라는 뜻입니다.

실패도 한 칸에 담긴다

작업이 실패하면 result 칸에 에러가 들어옵니다. 그 에러의 생김새도 정해져 있습니다.

OperationError: LRO가 실패했을 때 담기는 에러 정보. code·message·details 세 칸으로 되어 있습니다.

interface OperationError {
    code: string;       // 기계가 읽는 에러 코드 (예: "TIMEOUT")
    message: string;    // 사람이 읽는 설명
    details?: any;      // 추가 정보
}

번호표의 3가지 상태

운송장은 늘 셋 중 하나입니다.

대기 중(pending):  done=false, 아직 결과 없음
성공(resolved):    done=true,  result=성공 결과
실패(rejected):    done=true,  result=에러
비유 API/코드 위험·주의
배송 중 pending (done=false) 결과를 아직 읽으면 안 됨
도착 완료 resolved (done=true, 결과 있음) 정상적으로 결과 사용 가능
배송 실패·반송 rejected (done=true, 에러 있음) done은 true지만 결과가 아니라 에러임

결과가 꼭 거창할 필요는 없다

result 칸(ResultT)에 꼭 대단한 리소스가 들어갈 필요는 없습니다.

  • 그냥 "다 됐다"만 알리면 → 빈 값을 돌려줘도 됩니다.
  • 번역 결과처럼 임시 데이터면 → TranslationResult 같은 가벼운 타입을 만들면 됩니다.
  • 진행 정보가 필요 없으면 → metadata 타입을 null로 둬도 됩니다.

10.4 번호표를 어디에 둘까 — 리소스 계층

이런 적 있죠?

회사에서 택배 운송장을 부서별 서랍에 따로 넣었더니, "전체 배송 현황 좀 뽑아줘" 할 때 서랍을 다 뒤져야 했던 적 있죠? 차라리 운송장을 한 통에 모았으면 편했을 겁니다.

Operation을 어디에 저장할지도 똑같은 고민입니다.

두 가지 선택지

중첩 리소스: 특정 리소스 밑에 넣는 방식 (예: chatRooms/1/operations/2). 부서별 서랍 방식입니다.

최상위 컬렉션: 모든 Operation을 한곳에 모으는 방식 (예: /operations/1). 한 통에 모으는 방식입니다.

비유 API/코드 위험·주의
부서별 서랍 중첩 chatRooms/1/operations/2 여러 리소스에 걸친 작업은 어느 서랍에? 전체 현황 뽑기 어려움
한 통에 모음 최상위 /operations/2 (권장) 한 번에 전체 검색 가능, 여러 리소스 작업도 문제없음

잘못된 예 vs 올바른 예

잘못된 예 — 중첩
  chatRooms/1/operations/op-1
  chatRooms/2/operations/op-2
  문제: "텍스트→오디오 변환"은 어느 채팅방 밑에 둘까? 애매함
  문제: 전체 작업 목록 = 채팅방마다 따로 조회해야 함

올바른 예 — 최상위 컬렉션
  /operations/op-1
  /operations/op-2
  한 번의 조회로 전체 작업 검색 OK

작업을 시작하는 메서드는 LRO를 돌려준다

오래 걸리는 create라면, ChatRoom 대신 Operation을 돌려줍니다.

@post("/chatRooms")
CreateChatRoom(req: CreateChatRoomRequest):
    Operation<ChatRoom, CreateChatRoomMetadata>;
    // ChatRoom이 아니라 Operation을 돌려줌!
    // 작업이 끝나면 그 안의 result가 ChatRoom이 됨

10.5 결과 받기 (1) 폴링 — "다 됐어?" 물어보기

이런 적 있죠?

택배 앱을 자꾸 새로고침하면서 "어디쯤 왔나" 확인한 적 있죠? 처음엔 1분마다 보다가, 안 오니까 점점 텀을 늘려서 보게 됩니다.

이렇게 직접 물어보는 게 폴링입니다.

그게 바로 폴링이다

폴링(Polling): 클라이언트가 주기적으로 "끝났어?" 하고 직접 물어보는 방식. 택배 앱 새로고침과 같습니다.

물어볼 때 쓰는 표준 메서드가 GetOperation입니다.

GetOperation: 운송장번호(id)로 Operation의 지금 상태를 조회하는 표준 메서드.

비유 API/코드 위험·주의
택배 앱 새로고침 while(!done) { GetOperation(id) } 너무 자주 누르면 서버에 부담
새로고침 텀을 점점 늘림 지수 백오프 (1초→2초→4초→8초) 텀을 안 늘리면 요청 폭주

지수 백오프(Exponential Backoff): 물어보는 간격을 점점 늘리는 전략. 1초, 2초, 4초, 8초처럼 두 배씩 늘려 서버 부담을 줄입니다.

코드로

작업 시작 → done이 될 때까지 반복 조회 → 결과 반환, 이 3단계입니다.

async function createChatRoomAndWait(): Promise<ChatRoom> {
    // 1단계: 작업 시작 → Operation(운송장) 받음
    let op = CreateChatRoom({ resource: { title: "Cool Chat" } });

    // 2단계: 끝날 때까지 반복해서 물어봄
    while (!op.done) {
        op = GetOperation({ id: op.id });   // 최신 상태 가져오기
        await new Promise(r => setTimeout(r, 1000));  // 1초 쉬고
    }

    // 3단계: 결과 반환
    return op.result as ChatRoom;
}

GetOperation의 정의는 이렇게 생겼습니다.

@get("/{id=operations/*}")
GetOperation<ResultT, MetadataT>(req: GetOperationRequest):
    Operation<ResultT, MetadataT>;
// 매개변수화되어 있어서 어떤 작업에든 다 쓸 수 있음

interface GetOperationRequest { id: string; }

폴링의 좋은 점과 아쉬운 점

비유 API/코드 위험·주의
직접 물어보니 단순함 가장 간단하고 이해하기 쉬움 요청이 계속 반복돼 낭비
내가 원할 때 물어봄 클라이언트가 타이밍을 제어 결과를 바로는 못 앎(물어봐야 앎)
원하는 시점에 정확히 요청 가능 네트워크 트래픽 증가

10.6 결과 받기 (2) 대기 — "다 되면 알려줘"

이런 적 있죠?

식당에서 진동벨을 받으면, 카운터에 계속 가서 "됐어요?" 물을 필요가 없죠. 앉아서 다른 일 하다가 벨이 울리면 가지러 갑니다.

폴링이 직접 묻는 거라면, 대기는 진동벨입니다.

그게 바로 대기다

WaitOperation: 서버에 연결을 걸어 두고 작업이 끝날 때까지 기다리는 메서드. 진동벨을 받아 두는 것과 같습니다.

블로킹(Blocking): 응답이 올 때까지 연결을 열어 두는 방식. 서버가 작업을 끝내자마자 바로 응답을 보냅니다.

비유 API/코드 위험·주의
카운터에 계속 물음 폴링 — 요청 여러 번 자주 물어 부담·낭비
진동벨 받고 앉아 있음 대기(WaitOperation) — 요청 한 번 연결이 끊기면 처음부터 다시 폴링해야 할 수 있음

코드로 — 반복문이 사라진다

function createChatRoomAndWait(): Promise<ChatRoom> {
    // 1단계: 작업 시작
    let op = CreateChatRoom({ resource: { title: "Cool Chat" } });

    // 2단계: 끝날 때까지 대기 — while 반복문이 없다!
    op = WaitOperation({ id: op.id });

    // 3단계: 결과 반환
    return op.result as ChatRoom;
}

정의는 이렇습니다. 상태를 바꾸지 않으니 GET을 씁니다.

@get("/{id=operations/*}:wait")   // 멱등성을 위해 GET
WaitOperation<ResultT, MetadataT>(req: WaitOperationRequest):
    Operation<ResultT, MetadataT>;

interface WaitOperationRequest { id: string; }

한 걸음 더 ▸ 서버가 응답을 잠시 붙들고 있다가 일이 끝나면 보내는 기법을 'HTTP Long Polling'이라고 부릅니다. 지금은 "대기는 서버가 응답을 늦게 보내 주는 것" 정도만 알면 충분합니다.

폴링 vs 대기 한눈에

표준 규칙(기본 안전판)부터 말하면: 간단하게 가고 싶으면 폴링, 결과를 빨리 받고 싶으면 대기입니다.

비유 폴링 대기
누가 주도? 클라이언트(내가 물음) 서버(서버가 알려줌)
요청 횟수 여러 번 한 번
결과 빠름? 약간 지연 거의 즉시
연결 끊기면? 다시 물으면 됨 처음부터 다시 시작할 수 있음
진행률 보기 물을 때마다 가능 끝날 때만

주의: 대기 도중 연결이 끊기면 처음부터 폴링을 다시 시작해야 할 수 있습니다.


10.7 실패도 결과다 — 에러 처리

이런 적 있죠?

택배 조회를 했는데 "조회 성공"이라고 떠서 안심했더니, 내용을 보니 "배송 실패: 주소 오류"였던 적 있죠? 조회가 성공한 것과, 배송이 성공한 것은 전혀 다른 얘기입니다.

LRO 에러가 딱 이 구조입니다.

그게 바로 LRO 에러의 함정이다

일반 API 에러는 즉시 HTTP 에러로 옵니다.

잘못된 주소 조회 — 일반 에러
GET /chatRooms/999  →  404 Not Found   (HTTP 자체가 에러)

LRO 에러는 다릅니다. 조회(GetOperation)는 성공(200 OK)인데, 그 안의 result에 에러가 들어 있습니다.

LRO 에러
GET /operations/op-1  →  200 OK   (조회는 성공!)
{
  "id": "op-1",
  "done": true,
  "result": {                       ← 여기 안에 에러가 있음
    "code": "ANALYSIS_FAILED",
    "message": "채팅방에 메시지가 없습니다",
    "details": { "chatRoom": "chatRooms/1" }
  }
}
비유 API/코드 위험·주의
조회는 됐는데 내용이 '배송 실패' GetOperation은 200, result는 에러 200 떴다고 성공이라 착각 금지
운송장이 없어서 조회 자체 실패 GET /operations/없는ID → 404 이건 조회 실패(다른 얘기)

중요: "GetOperation이 에러를 냈다"와 "LRO 작업이 실패했다"는 다릅니다. 전자는 운송장 조회 자체가 안 된 것, 후자는 운송장은 멀쩡한데 작업 결과가 실패인 것입니다.

코드로 — 결과가 에러인지 먼저 확인

result가 에러인지 아닌지부터 검사하고 나눠야 합니다.

function isOperationError(result: any): result is OperationError {
    const err = result as OperationError;
    return err.code !== undefined && err.message !== undefined;
}

function createChatRoomAndWait() {
    let op = CreateChatRoom({ resource: { title: "Cool Chat" } });
    op = WaitOperation({ id: op.id });

    if (isOperationError(op.result)) {
        // 에러 처리: 로그, 재시도, 사용자 알림 등
    } else {
        return op.result as ChatRoom;   // 정상 결과
    }
}

에러 코드 설계 — code로 분기하고 message는 사람용

에러 코드(code): 기계가 읽는 고정된 에러 식별자 (예: "QUOTA_EXCEEDED"). 코드로 프로그램 분기를 합니다.

에러 메시지(message): 사람이 읽는 설명. 문구는 언제든 바뀔 수 있으니, message로 분기하면 안 됩니다.

비유 API/코드 위험·주의
택배 실패코드 'E07' code로 분기 코드는 안 바뀌니 안전
"주소가 틀렸어요" 안내문 message는 사람용 문구 바뀌면 분기 코드가 깨짐
잘못된 예: message만 있고 code 없음 → 문장을 파싱해서 분기 → 문구 바꾸면 깨짐
올바른 예: code="QUOTA_EXCEEDED", message="일일 한도 초과", details={limit:100}

10.8 진행 상황 보여주기 — 메타데이터

이런 적 있죠?

다운로드 막대가 "99%"에서 한참 멈춰 있거나, 갑자기 "80%"로 줄어든 걸 보고 황당했던 적 있죠? 퍼센트 하나만 믿으면 이런 일이 생깁니다.

진행 정보는 metadata 칸에 담는다

작업이 "지금 어디까지 됐는지"는 Operation의 metadata 칸에 담습니다.

MetadataT: Operation의 진행 정보를 담는 부분. 작업마다 자유롭게 설계합니다. "150개 중 23개 처리" 같은 걸 여기 넣습니다.

// 메시지 분석 작업이 LRO를 돌려주는 예
@post("/{parent=chatRooms/*}/messages:analyze")
AnalyzeMessages(req): Operation<MessageAnalysis, AnalyzeMessagesMetadata>;

// 결과 타입
interface MessageAnalysis {
    chatRoom: string;
    messageCount: number;
    participantCount: number;
    userGradeLevels: map<string, number>;
}

// 진행 정보 타입
interface AnalyzeMessagesMetadata {
    chatRoom: string;
    messagesProcessed: number;   // 처리된 메시지 수
    messagesCounted: number;     // 전체 메시지 수
}

폴링하면서 진행 상황 출력

async function analyzeWithProgress() {
    let op = AnalyzeMessages({ parent: 'chatRooms/1' });
    while (!op.done) {
        op = GetOperation({ id: op.id });
        let m = op.metadata as AnalyzeMessagesMetadata;
        console.log(`${m.messagesProcessed} / ${m.messagesCounted} 처리됨...`);
        await new Promise(r => setTimeout(r, 1000));
    }
    return op.result as MessageAnalysis;
}

실행하면 이렇게 찍힙니다.

0 / 150 처리됨...
23 / 150 처리됨...
67 / 150 처리됨...
150 / 150 처리됨...
분석 완료!

퍼센트 하나만 쓰면 왜 안 될까

진행률(Progress): 작업이 얼마나 됐는지 나타내는 값. 꼭 퍼센트일 필요는 없습니다.

비유 API/코드 위험·주의
다운로드 막대가 80%→75%로 줄어듦 퍼센트 하나만 저장 퍼센트는 역행할 수 있어 사용자가 혼란
"150개 중 23개" 영수증식 처리 개수/전체 개수 의미가 분명해 혼란 적음
잘못된 예: progress = 80   → 새 데이터 발견되면 75로 줄어듦 → "왜 줄지?"
올바른 예:
  - 처리된 레코드 수 (23 / 150)
  - 처리된 바이트 수 (1.2GB / 5GB)
  - 작업 단계 ("수집 중" → "분석 중" → "결과 생성 중")

metadata는 아무 모양이나 담을 수 있으니, 작업에 가장 잘 맞는 방식을 고르면 됩니다.


10.9 작업 취소 — CancelOperation

이런 적 있죠?

인쇄소에 1000부를 주문했는데 표지가 틀린 걸 발견하고 "멈춰주세요!" 전화한 적 있죠? 이미 찍힌 200부는 폐기하고요. 그 폐기가 '정리'입니다.

그게 바로 취소다

CancelOperation: 진행 중인 작업을 멈추는 메서드. 인쇄 중단 전화와 같습니다.

정리(Cleanup): 취소 후 중간에 만들어진 결과물(파일 등)을 치우는 일. 잘못 찍힌 200부 폐기에 해당합니다.

@post("/{id=operations/*}:cancel")
CancelOperation<ResultT, MetadataT>(req): Operation<ResultT, MetadataT>;
// 취소가 끝나면 Operation을 돌려줌 (done=true)

interface CancelOperationRequest { id: string; }
비유 API/코드 위험·주의
인쇄 중단 요청 CancelOperation 호출 취소도 시간이 걸릴 수 있음
찍힌 200부 폐기 중간 결과물 cleanup 못 치우면 어디 있는지 알려줘야 함

취소의 4가지 규칙

표준 규칙(기본 안전판)은 이렇습니다.

1. 취소도 시간이 걸릴 수 있다 → WaitOperation으로 취소 완료까지 기다릴 수 있다
2. 작업을 시작 안 한 것처럼 되돌린다 → 중간 파일·데이터를 정리(cleanup)
3. 정리가 불가능하면 → 메타데이터에 "이거 남았어요"를 남겨 사용자가 직접 치우게
4. 모든 작업이 취소를 지원할 필요는 없다

한 걸음 더 ▸ 4번을 기억하세요. 로켓 발사 API에 '취소'가 있어도 발사 직후엔 의미가 없죠. 이점이 없으면 굳이 만들지 않는 게 맞습니다. 기능은 많을수록 좋은 게 아닙니다.


10.10 일시 정지·재개 — Pause / Resume

이런 적 있죠?

영화를 보다 잠깐 멈췄다가, 나중에 그 장면부터 이어 본 적 있죠? 처음부터 다시 보지 않고요. 작업도 이렇게 멈췄다 이어 할 수 있습니다.

그게 바로 일시 정지·재개다

PauseOperation: 진행 중인 작업을 잠깐 멈추는 메서드. 영화 일시정지와 같습니다.

ResumeOperation: 멈춘 작업을 다시 이어 가는 메서드. 멈춘 장면부터 재생입니다.

"멈춤" 상태를 어디에 저장할까

비유 API/코드 위험·주의
운송장 양식 자체에 '멈춤' 칸 추가 Operation에 새 필드 추가 멈춤이 필요 없는 작업까지 칸을 떠안음
진행정보(metadata) 안에만 '멈춤' 추가 metadata에 paused 불리언 (권장) 필요한 작업만 가짐, 유연함

paused 필드: 메타데이터에 넣는 참/거짓 값. 지금 멈춤 상태인지를 나타냅니다.

interface AnalyzeMessagesMetadata {
    chatRoom: string;
    paused: boolean;            // 멈춤 상태
    messagesProcessed: number;
    messagesCounted: number;
}
@post("/{id=operations/*}:pause")
PauseOperation<ResultT, MetadataT>(req): Operation<ResultT, MetadataT>;

@post("/{id=operations/*}:resume")
ResumeOperation<ResultT, MetadataT>(req): Operation<ResultT, MetadataT>;

interface PauseOperationRequest { id: string; }
interface ResumeOperationRequest { id: string; }

핵심 규칙

1. 멈춤·재개 둘 다 실제로 동작하게 만들어야 한다 (한쪽만 되면 곤란)
2. 멈춤·재개가 성공했을 때만 결과를 돌려준다
3. 데이터 복사 중 멈추면 → 어디까지 복사했는지(포인터)를 저장하고 멈춤
4. 구체적인 구현은 API 설계자가 정한다

한 걸음 더 ▸ 취소와 마찬가지로, 모든 작업에 일시정지가 필요한 건 아닙니다. 필요한 작업에만 넣으세요.


10.10½ 직접 돌려보기 (1) — 진짜 동작하는 LRO 서버

이런 적 있죠?

설명만 잔뜩 읽고 나서 "그래서 코드로는 어떻게 생겼는데?" 싶었던 적 있죠? 지금까지 규칙을 세 개나 배웠습니다. 취소는 '중간 정리', 일시정지는 '풀릴 때까지 대기', :wait은 '서버가 끝날 때까지 붙들기'. 이 셋이 실제 코드 한 덩어리에서 어떻게 맞물리는지 짧게 보고 갑시다.

규칙 3개를 코드 한 곳에 모으면

작업 저장소는 그냥 딕셔너리 하나면 됩니다. 운송장(Operation)을 만들고, 끝내고, 실패시키는 헬퍼 셋. 그리고 취소 플래그와 :wait 블로킹 루프.

operations = {}   # 작업 저장소 (운송장 보관함 — 실제로는 DB)

# 헬퍼 1: 운송장 발급 (done=False로 시작)
def create_op(meta):
    op_id = f"operations/{len(operations)+1}"
    operations[op_id] = {"id": op_id, "done": False, "result": None, "metadata": meta}
    return operations[op_id]

# 헬퍼 2: 성공으로 마감
def complete_op(op_id, result):
    operations[op_id].update(done=True, result=result)

# 헬퍼 3: 실패로 마감 (에러를 result 칸에)
def fail_op(op_id, code, msg):
    operations[op_id].update(done=True, result={"code": code, "message": msg})

# 백그라운드 작업: 취소 플래그·일시정지 확인하며 진행
def run(op_id):
    meta = operations[op_id]["metadata"]
    for i in range(0, 151, 30):
        if meta.get("cancelled"):                 # 취소 → 중간 정리 후 실패 마감
            fail_op(op_id, "CANCELLED", "사용자 취소"); return
        while meta.get("paused"):                  # 일시정지 → 풀릴 때까지 대기
            time.sleep(0.5)
        meta["messagesProcessed"] = i              # 진행 정보 갱신
        time.sleep(0.5)
    complete_op(op_id, {"messageCount": 150})      # 다 끝나면 성공 마감

# :wait 블로킹 — done이 될 때까지 서버가 붙들고 있다가 응답
def wait_op(op_id):
    while not operations[op_id]["done"]:
        time.sleep(0.5)
    return operations[op_id]
비유 API/코드 위험·주의
운송장 보관함 operations 딕셔너리 프로세스 끄면 사라짐(진짜 서버는 DB에 저장)
발급·완료·반송 도장 create_op / complete_op / fail_op 마감 도장은 한 번만(done=True 후 또 찍지 말 것)
"취소 요청 들어왔나?" 확인 meta["cancelled"] 플래그 루프 안에서 매번 확인해야 즉시 멈춤
진동벨 누름 참기 wait_op의 while 루프 끝나야만 응답 — 연결을 오래 붙들고 있음

잘못된 예 vs 올바른 예

잘못된 예 — 취소 플래그를 루프 밖에서 한 번만 확인
def run(op_id):
    if meta.get("cancelled"): return   # 시작할 때 딱 한 번 → 도중 취소는 못 잡음
    for i in range(...): ...            # 한참 도는 동안 취소 무시됨

올바른 예 — 루프 안에서 매 단계 확인 (위 run 코드)
    for i in range(...):
        if meta.get("cancelled"): fail_op(...); return   # 매 단계 점검 → 즉시 멈춤

핵심: 취소·일시정지는 백그라운드 루프가 매 단계 스스로 확인하고, :wait은 done이 될 때까지 서버가 붙들고 기다린다. 규칙이 코드로는 이렇게 단순합니다.


10.10¾ 직접 돌려보기 (2) — 폴링 vs 대기, requests로 비교

이런 적 있죠?

앞에서 폴링과 대기를 표로 비교했지만, "실제 호출은 어떻게 다른데?" 궁금했죠? 같은 작업을 폴링으로 받을 때와 대기로 받을 때, 클라이언트 코드가 어떻게 달라지는지 진짜 requests로 나란히 봅시다.

폴링 방식 — 내가 계속 물어본다

import requests, time
BASE = "http://localhost:5001"

# 1단계: 작업 시작 → 운송장(Operation) 받음
op = requests.post(f"{BASE}/chatRooms/1/messages:analyze").json()

# 2단계: done이 될 때까지 내가 반복해서 GET (여러 번 요청)
while not op["done"]:
    time.sleep(2)                              # 잠깐 쉬고
    op = requests.get(f"{BASE}/{op['id']}").json()
    m = op.get("metadata", {})
    print(f"진행: {m.get('messagesProcessed', 0)} / 150")

# 3단계: 결과 사용
print("완료!", op["result"])

대기 방식 — 서버가 알려줄 때까지 맡긴다

import requests
BASE = "http://localhost:5001"

# 1단계: 작업 시작
op = requests.post(f"{BASE}/chatRooms/1/messages:analyze").json()

# 2단계: :wait 한 번만 호출 — while 반복문이 없다! (요청 한 번)
op = requests.get(f"{BASE}/{op['id']}:wait").json()

# 3단계: 결과 사용
print("완료!", op["result"])
비유 폴링 (requests.get 반복) 대기 (:wait 한 번)
카운터에 계속 물음 vs 진동벨 while 루프로 GET 여러 번 :wait GET 딱 한 번
코드 길이 반복문·sleep 필요 한 줄로 끝
진행 상황 물을 때마다 metadata 확인 가능 끝나야 결과가 옴

핵심: 두 방식 모두 시작은 같은 POST 한 번. 다른 건 결과를 받는 부분뿐입니다 — 폴링은 while로 GET을 여러 번, 대기는 :wait을 한 번. 코드만 봐도 "내가 묻느냐, 서버가 알려주느냐"의 차이가 그대로 드러납니다.


10.11 작업 찾기 — ListOperations

이런 적 있죠?

작업을 시작해 두고 그 번호를 메모지에만 적었는데, 컴퓨터가 갑자기 꺼져서 번호를 잃어버린 적 있죠? 다행히 택배사 시스템엔 운송장이 남아 있어서 다시 찾을 수 있습니다.

그게 바로 탐색이다

ListOperations: 서버에 저장된 모든 Operation을 목록으로 조회하는 표준 메서드.

작업 ID를 잃어버려도, 서버엔 LRO가 남아 있으니 목록으로 다시 찾을 수 있습니다. (LRO가 서버에 영속 저장되는 게 이래서 유용합니다.)

@get("/operations")
ListOperations<ResultT, MetadataT>(req): ListOperationsResponse<ResultT, MetadataT>;

interface ListOperationsRequest { filter: string; }   // 조건
interface ListOperationsResponse<ResultT, MetadataT> {
    results: Operation<ResultT, MetadataT>[];
}

필터로 골라보기

필터링(Filtering): 조건에 맞는 작업만 골라 보는 기능. "미완료만 보기"처럼요.

비유 API/코드 위험·주의
"배송 중인 것만 보여줘" filter: "done=false" 조건 없이 다 받으면 양이 많을 수 있음
"멈춘 작업만" filter: "metadata.paused=true" metadata 필드 이름이 정확해야 함
"1번 방 분석만" filter: "metadata.chatRoom=chatRooms/1"
잘못된 예: 전체 목록 받아서 코드에서 일일이 거르기 (비효율)
올바른 예: filter="done=false"로 서버가 미완료만 골라 주기

10.12 번호표를 언제까지 둘까 — 영속성

이런 적 있죠?

다 받은 택배 운송장을 평생 모아 두면 서랍이 터지겠죠? 배송 끝난 운송장은 어느 정도 지나면 버립니다.

LRO도 똑같습니다.

LRO는 좀 특수하다

영속성(Persistence): 데이터를 계속 저장해 두는 것.

일반 리소스(ChatRoom)와 달리 LRO는 성격이 다릅니다.

비유 일반 리소스(ChatRoom) LRO(Operation)
만든 주체 내가 직접 만듦(CreateChatRoom) 다른 작업의 부산물로 자동 생성
수명 영구적 끝나면 금세 안 중요해짐
너무 쌓이면 정상 스토리지 낭비

만료 시점을 넣는다

expireTime: Operation이 사라질 시점. 이 시간이 지나면 자동 삭제됩니다.

롤링 윈도우 삭제: 작업이 끝난 시점을 기준으로 "30일 뒤 삭제"처럼 일정 기간 후 지우는 정책.

interface Operation<ResultT, MetadataT> {
    id: string;
    done: boolean;
    expireTime: Date;     // 만료 시점 (예: 30일 후)
    result?: ResultT | OperationError;
    metadata?: MetadataT;
}

보관 정책 3가지

비유 API/코드 위험·주의
운송장 평생 보관 영구 보관 구현은 쉽지만 쌓이면 성능 문제
30일 뒤 자동 폐기 롤링 윈도우 삭제 (권장) expireTime으로 만료 표시
복잡한 조건부 폐기 규칙 기반 삭제 혼동·복잡, 비추천
잘못된 예: "결과 종류마다 만료 기간을 다르게" → 같은 작업인데 수명이 달라 혼란
올바른 예: 같은 작업이면 같은 만료 기간(예: 모두 30일)

중요: 작업 결과 타입에 따라 만료 시간이 달라지면 안 됩니다. 같은 작업이면 같은 만료 시간을 써야 혼란이 없습니다.


10.13 전체 API 한눈에 보기

지금까지 본 메서드를 모으면 이렇게 됩니다.

메서드 HTTP 하는 일
AnalyzeMessages POST 분석 작업 시작 → LRO 반환
GetOperation GET 작업 상태 조회(폴링)
ListOperations GET 작업 목록 조회
WaitOperation GET 작업 완료까지 대기
CancelOperation POST 작업 취소
PauseOperation POST 작업 일시 정지
ResumeOperation POST 작업 재개

한 걸음 더 ▸ 상태를 바꾸지 않는 조회·대기는 GET, 상태를 바꾸는 취소·정지·재개는 POST입니다. GET은 여러 번 불러도 결과가 같다는 약속(멱등성)이 있어서 조회에 적합합니다.

abstract class ChatRoomApi {
    @post("/{parent=chatRooms/*}/messages:analyze")
    AnalyzeMessages(req): Operation<MessageAnalysis, AnalyzeMessagesMetadata>;

    @get("/{id=operations/*}")
    GetOperation<R, M>(req): Operation<R, M>;

    @get("/operations")
    ListOperations<R, M>(req): ListOperationsResponse<R, M>;

    @get("/{id=operations/*}:wait")
    WaitOperation<R, M>(req): Operation<R, M>;

    @post("/{id=operations/*}:cancel")
    CancelOperation<R, M>(req): Operation<R, M>;

    @post("/{id=operations/*}:pause")
    PauseOperation<R, M>(req): Operation<R, M>;

    @post("/{id=operations/*}:resume")
    ResumeOperation<R, M>(req): Operation<R, M>;
}

10.14 트레이드오프 — LRO도 공짜는 아니다

LRO는 강력하지만, 쓰면 따라오는 비용도 있습니다.

비유 장점 주의(트레이드오프)
진동벨 시스템 오래 걸리는 일을 비동기로 처리 그냥 좀 기다리는 게 더 간단할 때도 있음
운송장 추적 진행 상황 추적, 멈춤·재개·취소 가능 연결이 끊기면 진행 추적이 복잡해짐
양식 빈칸 채우기 다양한 작업에 재사용 제네릭 매개변수화라 일반 리소스보다 복잡

정리하면:

장점
- 오래 걸리는 작업을 비동기로 처리
- 진행 상황 추적 가능
- 일시정지·재개·취소 가능
- 기다리는 동안 다른 일 가능

주의
1. 충분히 기다리는 게 더 간단할 수도 있다 (분산 구조에 안 맞는 경우도)
2. 연결이 끊기면 진행 추적이 복잡해진다
3. 매개변수화 리소스는 보통 리소스보다 복잡하다 (부산물로 생성됨)
4. 사용이 강제되지 않는다 — 동기로 충분하면 그냥 동기로 쓰면 됨

한 걸음 더 ▸ 4번이 중요합니다. LRO를 배웠다고 모든 API를 LRO로 만들 필요는 없습니다. 짧게 끝나는 일은 그냥 동기로 두는 게 더 깔끔합니다.


연습문제

문제 1. 작업 리소스를 대상 리소스 밑에 넣지 말고 왜 최상위에 두는가?

  • 여러 리소스에 걸친 작업(예: 텍스트→오디오 변환)을 어느 밑에 둘지 애매하다.
  • 전체 작업 목록을 만들기 어렵다(채팅방마다 따로 조회해야 함).
  • 최상위 /operations면 한 번의 ListOperations로 전체를 검색할 수 있다.

문제 2. 왜 LRO를 만료시키는가?

  • LRO는 직접 만든 게 아니라 부산물이라 무한히 쌓인다.
  • 끝난 LRO는 시간이 지나면 금세 안 중요해진다.
  • 영구 보관하면 스토리지 부담이 커진다.
  • expireTime 기반 롤링 윈도우 삭제가 가장 깔끔하다.

문제 3. 대기 중인데 작업이 cancel로 중단되면 어떤 결과를 받는가?

  • CancelOperation이 작업을 완전히 취소한 뒤 Operation을 done=true로 돌려준다.
  • 대기 중이었다면 취소가 끝나는 순간 응답을 받는다.
  • result에는 OperationError가 담기고, code는 취소를 뜻하는 값(예: "CANCELLED")이다.
  • 중간 결과물을 못 치웠다면 메타데이터에 정리할 정보가 들어 있다.

문제 4. 진행률을 '완료 퍼센트' 단일 필드로 저장해도 될까?

  • 적절하지 않다.
  • 퍼센트는 역행할 수 있어(80%→75%) 사용자를 혼란시킨다.
  • 의미 있는 값이라는 보장이 없다(전체 양이 도중에 바뀔 수 있음).
  • "처리 개수/전체 개수", 처리 바이트양처럼 구체적 메타데이터가 낫다.
  • 메타데이터는 자유롭게 설계할 수 있으니 작업에 맞는 의미 있는 필드를 넣자.

요약

  • LRO는 웹 API의 프라미스(퓨처)에 해당한다. 오래 걸리는 작업의 완료 상태를 추적하는 번호표다.
  • LRO는 매개변수화 인터페이스다. 결과 타입과 진행 정보(메타데이터) 타입을 함께 다룬다.
  • 결과는 폴링("다 됐어?")이나 대기("다 되면 알려줘")로 받는다.
  • 실패도 결과의 한 종류다. 에러는 result 칸 안에 OperationError로 담긴다.
  • 진행 상황은 퍼센트보다 "처리 개수/전체" 같은 구체적 숫자가 낫다.
  • 사용자 메서드로 작업을 취소·일시정지·재개·탐색할 수 있다.
  • LRO는 저장해 두되 30일 등 일정 시간이 지나면 만료(삭제)시킨다.

다음 장 예고: 다음 장에서는 '한 번 더 눌러도 안전한 작업(재수행 가능 작업)'을 다룹니다. 지금 몰라도 됩니다.

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플